feat(agents): Cap'n Web connection transport on the WebSockets capability - #2156
feat(agents): Cap'n Web connection transport on the WebSockets capability#2156mattzcarey wants to merge 5 commits into
Conversation
🦋 Changeset detectedLatest commit: 0274b8e The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
8d85c1e to
36b3e2e
Compare
a190bf2 to
c21a0da
Compare
c21a0da to
460ea53
Compare
460ea53 to
918a292
Compare
18dd0b4 to
5cc00a4
Compare
Lifecycle no longer models WebSockets — many hosts never use sockets,
and connection behavior was never really lifecycle's concern. Hosts
that want connections install the WebSockets capability, which owns
the subsystem end to end:
new WebSockets({
handlers: { onConnect, onMessage, onClose },
callables: new RoomCallables()
})
The capability claims upgrades, accepts hibernating sockets (the
connection layer moved wholesale from lifecycle/connection.ts —
attachment format unchanged, so live hibernated connections survive
the migration), dispatches handlers inside the host invocation
boundary with the live connection in ambient context, reciprocates
close handshakes, closes owned connections on host destruction, and
answers getConnections()/getConnection(). Without it installed,
upgrades are declined.
Callables: pass an RpcTarget and its prototype methods are the remote
interface, served over a Cap'n Web session (?__agents_rpc=capnweb)
with native ReadableStream streaming, host-boundary dispatch, and
rpc/rpc:error events; agents/websockets/client ships a typed
CallablesClient. On an Agent, override the callables() template
method to supply the target (preferred); @callable()-decorated
methods remain the fallback interface, and the resolved interface is
served on every wire — the legacy JSON RPC protocol dispatches
explicit-target methods too, streaming ReadableStream results as
legacy chunk frames. The decorator machinery moved to
callable-decorator.ts so both consumers share one registry.
Agent migrates onto the capability in this change: it installs a
WebSockets instance whose handlers call through Agent's wrapped
hooks, and its connection reads go through the capability. Agent's
public API and wire protocol are unchanged — the full Agent suite
passes as-is. The think packages ride Agent's public API and need no
changes.
Lifecycle keeps only generic platform pass-throughs —
onWebSocketUpgrade to claim upgrades, onWebSocketMessage/Close/Error
to consume hibernation wakes for capability-owned sockets — and
LifecycleServices gains a narrow sockets surface (accept/get, not the
whole DurableObjectState) plus a connection/request scope on
runInHostContext, which Agent's host invoker threads into its
invocation context. The capability interaction contract — hooks,
services, and composition-root apertures as the only three channels,
with the claim/consume dispatch rules — is documented on
DurableObjectCapability.
PlainLifecycleObject and the examples/next/lifecycle example run on
the capability (handlers + callables), so the pre-existing Lifecycle
WebSocket, host-context, and hibernation suites prove handler parity.
New suites cover the callables endpoint on both wires: receiver
binding, host boundary, rpc events, streaming, merged interface
(explicit target preferred, decorators fallback), decorator-only
agents, and legacy-wire dispatch of target methods.
…lity
A connection can opt into a second wire (?__agents_transport=capnweb):
the same frames travel over a single Cap'n Web RPC session whose root
carries exactly one method — the message pipe. The capability's
handlers are transport-agnostic: both kinds of connection dispatch the
same onConnect/onMessage/onClose, appear in getConnections(), and
propagate connection.close(code, reason) to the client. Sessions are
plain in-memory WebSocketPairs — non-hibernating, replaced on
reconnect by _pk, and torn down through the same dispose path as the
hibernating sockets.
Because Agent rides the capability, useAgent({ transport: "capnweb" })
works against any Agent with the hook surface unchanged: identity,
state sync, call/stub RPC frames, and chat flow over the pipe, with
reconnection/backoff and terminal-close semantics matching the
PartySocket path. The browser pipe client is internal to the hook —
no new public client surface.
Covered by workers e2e (identity/state/MCP delivery, legacy RPC frames
over the pipe, mixed capnweb+hibernating broadcast, getConnections
visibility, close-code propagation and registry cleanup) and browser
hook tests (identity, state round-trip, call/stub).
5cc00a4 to
20e1d1d
Compare
…web re-applied next)
…onError; document the transport
There was a problem hiding this comment.
Devin Review found 2 new potential issues.
6 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| function isLocalHost(host: string): boolean { | ||
| if ( | ||
| host.startsWith("localhost:") || | ||
| host.startsWith("127.0.0.1:") || | ||
| host.startsWith("192.168.") || | ||
| host.startsWith("10.") || | ||
| host.startsWith("[::ffff:7f00:1]:") | ||
| ) { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
🟡 Local hosts use secure sockets
With host: "localhost" or "127.0.0.1" and no protocol, isLocalHost returns false. The client selects wss://, so ordinary local development servers cannot connect.
| function isLocalHost(host: string): boolean { | |
| if ( | |
| host.startsWith("localhost:") || | |
| host.startsWith("127.0.0.1:") || | |
| host.startsWith("192.168.") || | |
| host.startsWith("10.") || | |
| host.startsWith("[::ffff:7f00:1]:") | |
| ) { | |
| return true; | |
| } | |
| function isLocalHost(host: string): boolean { | |
| if ( | |
| host === "localhost" || | |
| host.startsWith("localhost:") || | |
| host === "127.0.0.1" || | |
| host.startsWith("127.0.0.1:") || | |
| host.startsWith("192.168.") || | |
| host.startsWith("10.") || | |
| host.startsWith("[::ffff:7f00:1]:") | |
| ) { | |
| return true; | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
| close: (code, reason) => { | ||
| closeCode = code ?? 1000; | ||
| closeReason = reason ?? "Connection closed"; | ||
| // Close the raw socket so the client observes the requested | ||
| // code/reason; fall back to disposing the RPC session when the | ||
| // code is outside the range WebSocket.close accepts. | ||
| try { | ||
| server.close(closeCode, closeReason); | ||
| } catch { | ||
| session?.[Symbol.dispose](); | ||
| } | ||
| void dispose(); |
There was a problem hiding this comment.
🟡 Invalid closes silently terminate sessions
When connection.close() receives an invalid code or oversized reason, it disposes the session instead of propagating the validation error. The same call on a hibernating connection throws, so handlers behave differently by transport.
Prompt for agents
The Cap'n Web connection close callback catches all errors from server.close(code, reason) and treats them as a reason to dispose the RPC session. WebSocket.close uses throws to report invalid close codes and oversized reasons, and the hibernating Connection exposes that behavior. Preserve the Connection contract by distinguishing disposal failures from caller validation failures, allowing invalid close arguments to propagate without silently replacing them with a generic session shutdown.
Was this helpful? React with 👍 or 👎 to provide feedback.
🔴 agents import sizesMeasured 268 runtime imports as minified bundles. The primary size is gzip; raw minified size is included for diagnosis. An existing import growing by more than 10% is marked red. This report is informational.
Compared Changed imports (94)
All 268 current runtime imports
Reported by agent-think[bot]. |
What
Purely additive: a second connection transport on the WebSockets capability, alongside the default
"partykit"(PartySocket) transport, which stays the default; nothing changes for existing clients.A client can opt into
?__agents_transport=capnweb: the same Agent-protocol frames travel over a single Cap'n Web RPC session whose root carries exactly one method — the message pipe. The capability's handlers are transport-agnostic: both kinds of connection dispatch the sameonConnect/onMessage/onClose/onError, appear ingetConnections(), and propagateconnection.close(code, reason)to the client. Sessions are replaced on reconnect (_pk) and torn down through the same dispose path.Because Agent rides the capability,
useAgent({ transport: "capnweb" })works against any Agent with the hook surface unchanged — identity, state sync,call/stubRPC frames, and chat all flow over the pipe, with reconnection/backoff and terminal-close semantics matching the PartySocket path. The pipe client is internal to the hook — no new public client surface.Trade-off: Cap'n Web transport connections are plain in-memory sockets — non-hibernating; the Durable Object stays pinned while one is open.
Docs
docs/agents/client-sdk.md: new Transport section plus thetransportoption in the hook reference.docs/agents/lifecycle.md: the WebSockets section now describes both wires (it previously stated there was no non-hibernating mode).Testing
getConnectionsvisibility, close-code propagation + registry cleanup.useAgentcapnweb (identity, state round-trip,call/stub).